Jelajahi Higher-Kinded Types (HKT) di TypeScript. Pelajari konsep, pentingnya, dan cara menirunya untuk kode yang abstrak, kuat, dan dapat digunakan kembali.
Membuka Abstraksi Tingkat Lanjut: Menyelami Lebih Dalam Higher-Kinded Types TypeScript
Dalam dunia pemrograman bertipe statis, para pengembang terus mencari cara baru untuk menulis kode yang lebih abstrak, dapat digunakan kembali, dan aman-tipe (type-safe). Sistem tipe TypeScript yang kuat, dengan fitur seperti generics, conditional types, dan mapped types, telah membawa tingkat keamanan dan ekspresivitas yang luar biasa ke ekosistem JavaScript. Namun, ada sebuah batasan abstraksi tingkat tipe yang masih berada di luar jangkauan TypeScript secara bawaan: Higher-Kinded Types (HKTs).
Jika Anda pernah ingin menulis fungsi yang generik tidak hanya terhadap tipe dari sebuah nilai, tetapi juga terhadap kontainer yang menampung nilai tersebut—seperti Array
, Promise
, atau Option
—maka Anda telah merasakan kebutuhan akan HKTs. Konsep ini, yang dipinjam dari pemrograman fungsional dan teori tipe, merupakan alat yang ampuh untuk menciptakan pustaka yang benar-benar generik dan dapat disusun (composable).
Meskipun TypeScript tidak mendukung HKTs secara langsung, komunitasnya telah menemukan cara-cara cerdas untuk menirunya. Artikel ini akan membawa Anda menyelami dunia Higher-Kinded Types. Kita akan menjelajahi:
- Apa itu HKTs secara konseptual, dimulai dari prinsip pertama dengan "kinds".
- Mengapa generics standar TypeScript tidak cukup.
- Teknik paling populer untuk meniru HKTs, terutama pendekatan yang digunakan oleh pustaka seperti
fp-ts
. - Aplikasi praktis HKTs untuk membangun abstraksi yang kuat seperti Functors, Applicatives, dan Monads.
- Keadaan saat ini dan prospek masa depan HKTs di TypeScript.
Ini adalah topik tingkat lanjut, tetapi memahaminya akan secara fundamental mengubah cara Anda berpikir tentang abstraksi tingkat tipe dan memberdayakan Anda untuk menulis kode yang lebih tangguh dan elegan.
Memahami Fondasi: Generics dan Kinds
Sebelum kita dapat melompat ke higher kinds, kita harus terlebih dahulu memiliki pemahaman yang kuat tentang apa itu "kind". Dalam teori tipe, sebuah kind adalah "tipe dari sebuah tipe". Ini menjelaskan bentuk atau aritas dari sebuah konstruktor tipe. Ini mungkin terdengar abstrak, jadi mari kita dasarkan pada konsep TypeScript yang sudah dikenal.
Kind *
: Tipe Sebenarnya (Proper Types)
Pikirkan tentang tipe-tipe sederhana dan konkret yang Anda gunakan setiap hari:
string
number
boolean
{ name: string; age: number }
Ini adalah tipe yang "terbentuk sepenuhnya". Anda dapat membuat variabel dari tipe-tipe ini secara langsung. Dalam notasi kind, ini disebut proper types, dan mereka memiliki kind *
(diucapkan "star" atau "type"). Mereka tidak memerlukan parameter tipe lain untuk menjadi lengkap.
Kind * -> *
: Konstruktor Tipe Generik
Sekarang pertimbangkan generics TypeScript. Tipe generik seperti Array
bukanlah tipe sebenarnya dengan sendirinya. Anda tidak dapat mendeklarasikan variabel let x: Array
. Ini adalah templat, cetak biru, atau konstruktor tipe. Ia membutuhkan parameter tipe untuk menjadi tipe sebenarnya.
Array
mengambil satu tipe (sepertistring
) dan menghasilkan tipe sebenarnya (Array
).Promise
mengambil satu tipe (sepertinumber
) dan menghasilkan tipe sebenarnya (Promise
).type Box
mengambil satu tipe (seperti= { value: T } boolean
) dan menghasilkan tipe sebenarnya (Box
).
Konstruktor tipe ini memiliki kind * -> *
. Notasi ini berarti mereka adalah fungsi di tingkat tipe: mereka mengambil tipe dengan kind *
dan mengembalikan tipe baru dengan kind *
.
Kind yang Lebih Tinggi: (* -> *) -> *
dan Seterusnya
Oleh karena itu, higher-kinded type adalah konstruktor tipe yang generik terhadap konstruktor tipe lain. Ia beroperasi pada tipe dengan kind yang lebih tinggi dari *
. Misalnya, sebuah konstruktor tipe yang mengambil sesuatu seperti Array
(sebuah tipe dengan kind * -> *
) sebagai parameter akan memiliki kind seperti (* -> *) -> *
.
Di sinilah kemampuan bawaan TypeScript mencapai batasnya. Mari kita lihat mengapa.
Keterbatasan Generics Standar TypeScript
Bayangkan kita ingin menulis fungsi map
generik. Kita tahu cara menulisnya untuk tipe spesifik seperti Array
:
function mapArray<A, B>(arr: A[], f: (a: A) => B): B[] {
return arr.map(f);
}
Kita juga tahu cara menulisnya untuk tipe Box
kustom kita:
type Box<A> = { value: A };
function mapBox<A, B>(box: Box<A>, f: (a: A) => B): Box<B> {
return { value: f(box.value) };
}
Perhatikan kesamaan strukturalnya. Logikanya identik: ambil sebuah kontainer dengan nilai bertipe A
, terapkan fungsi dari A
ke B
, dan kembalikan kontainer baru dengan bentuk yang sama tetapi dengan nilai bertipe B
.
Langkah alami berikutnya adalah melakukan abstraksi terhadap kontainer itu sendiri. Kita menginginkan satu fungsi map
yang berfungsi untuk setiap kontainer yang mendukung operasi ini. Upaya pertama kita mungkin akan terlihat seperti ini:
// INI BUKAN TYPESCRIPT YANG VALID
function map<F, A, B>(container: F<A>, f: (a: A) => B): F<B> {
// ... bagaimana cara mengimplementasikannya?
}
Sintaks ini langsung gagal. TypeScript menginterpretasikan F
sebagai variabel tipe biasa (dengan kind *
), bukan sebagai konstruktor tipe (dengan kind * -> *
). Sintaks F
ilegal karena Anda tidak dapat menerapkan parameter tipe ke tipe lain seperti sebuah generic. Inilah masalah inti yang ingin dipecahkan oleh emulasi HKT. Kita perlu cara untuk memberitahu TypeScript bahwa F
adalah placeholder untuk sesuatu seperti Array
atau Box
, bukan string
atau number
.
Mengemulasi Higher-Kinded Types di TypeScript
Karena TypeScript tidak memiliki sintaks bawaan untuk HKTs, komunitas telah mengembangkan beberapa strategi pengkodean. Pendekatan yang paling luas dan teruji melibatkan penggunaan kombinasi antarmuka, pencarian tipe, dan augmentasi modul. Ini adalah teknik yang terkenal digunakan oleh pustaka fp-ts
.
Metode URI dan Pencarian Tipe
Metode ini terbagi menjadi tiga komponen utama:
- Tipe
Kind
: Sebuah antarmuka pembawa generik untuk merepresentasikan struktur HKT. - URI: Literal string unik untuk mengidentifikasi setiap konstruktor tipe.
- Pemetaan URI-ke-Tipe: Sebuah antarmuka yang menghubungkan URI string ke definisi konstruktor tipe mereka yang sebenarnya.
Mari kita bangun langkah demi langkah.
Langkah 1: Antarmuka `Kind`
Pertama, kita mendefinisikan antarmuka dasar yang akan dipatuhi oleh semua HKT yang kita emulasikan. Antarmuka ini bertindak sebagai kontrak.
export interface HKT<URI, A> {
readonly _URI: URI;
readonly _A: A;
}
Mari kita bedah ini:
_URI
: Properti ini akan menampung tipe literal string unik (misalnya,'Array'
,'Option'
). Ini adalah pengidentifikasi unik untuk konstruktor tipe kita (F
dalam imajinasi kitaF
). Kita menggunakan garis bawah di depan untuk menandakan bahwa ini hanya untuk penggunaan tingkat tipe dan tidak akan ada saat runtime._A
: Ini adalah "tipe hantu" (phantom type). Ia menampung parameter tipe dari kontainer kita (A
dalamF
). Ia tidak berhubungan dengan nilai runtime tetapi sangat penting bagi pemeriksa tipe untuk melacak tipe dalamnya.
Terkadang Anda akan melihat ini ditulis sebagai Kind
. Penamaannya tidak kritis, tetapi strukturnya iya.
Langkah 2: Pemetaan URI-ke-Tipe
Selanjutnya, kita memerlukan sebuah registri pusat untuk memberitahu TypeScript tipe konkret mana yang sesuai dengan URI tertentu. Kita mencapainya dengan sebuah antarmuka yang dapat kita perluas menggunakan augmentasi modul.
export interface URItoKind<A> {
// Ini akan diisi oleh modul-modul yang berbeda
}
Antarmuka ini sengaja dibiarkan kosong. Ia berfungsi sebagai pengait (hook). Setiap modul yang ingin mendefinisikan higher-kinded type akan menambahkan entri ke dalamnya.
Langkah 3: Mendefinisikan Tipe Bantuan `Kind`
Sekarang, kita membuat tipe utilitas yang dapat menyelesaikan sebuah URI dan parameter tipe kembali menjadi tipe konkret.
export type Kind<URI extends keyof URItoKind<any>, A> = URItoKind<A>[URI];
Tipe Kind
ini melakukan keajaiban. Ia mengambil sebuah URI
dan sebuah tipe A
. Kemudian ia mencari URI
di dalam pemetaan `URItoKind` kita untuk mengambil tipe konkretnya. Sebagai contoh, `Kind<'Array', string>` harus diselesaikan menjadi `Array
Langkah 4: Mendaftarkan Tipe (misalnya, `Array`)
Untuk membuat sistem kita mengetahui tipe bawaan Array
, kita perlu mendaftarkannya. Kita melakukan ini menggunakan augmentasi modul.
// Di dalam file seperti `Array.ts`
// Pertama, deklarasikan URI unik untuk konstruktor tipe Array
export const URI = 'Array';
declare module './hkt' { // Asumsikan definisi HKT kita ada di `hkt.ts`
interface URItoKind<A> {
readonly [URI]: Array<A>;
}
}
Mari kita uraikan apa yang baru saja terjadi:
- Kita mendeklarasikan konstanta string unik
URI = 'Array'
. Menggunakan konstanta memastikan kita tidak salah ketik. - Kita menggunakan
declare module
untuk membuka kembali modul./hkt
dan menambahkan (augment) antarmukaURItoKind
. - Kita menambahkan properti baru ke dalamnya: `readonly [URI]: Array`. Ini secara harfiah berarti: "Ketika kuncinya adalah string 'Array', tipe yang dihasilkan adalah
Array
."
Sekarang, tipe Kind
kita berfungsi untuk `Array`! Tipe `Kind<'Array', number>` akan diselesaikan oleh TypeScript sebagai URItoKind
, yang, berkat augmentasi modul kita, menjadi `Array
Menyatukan Semuanya: Fungsi `map` Generik
Dengan pengkodean HKT kita, kita akhirnya bisa menulis fungsi `map` abstrak yang kita impikan. Fungsi itu sendiri tidak akan generik; sebaliknya, kita akan mendefinisikan antarmuka generik bernama Functor
yang menggambarkan setiap konstruktor tipe yang dapat dipetakan (mapped over).
// Di `Functor.ts`
import { HKT, Kind, URItoKind } from './hkt';
export interface Functor<F extends keyof URItoKind<any>> {
readonly URI: F;
readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}
Antarmuka Functor
ini sendiri bersifat generik. Ia mengambil satu parameter tipe, F
, yang dibatasi untuk menjadi salah satu URI terdaftar kita. Ia memiliki dua anggota:
URI
: URI dari functor (misalnya,'Array'
).map
: Sebuah metode generik. Perhatikan tanda tangannya: ia mengambil `Kind` dan sebuah fungsi, dan mengembalikan `Kind `. Inilah `map` abstrak kita!
Sekarang kita dapat menyediakan instance konkret dari antarmuka ini untuk `Array`.
// Di `Array.ts` lagi
import { Functor } from './Functor';
// ... pengaturan HKT Array sebelumnya
export const array: Functor<typeof URI> = {
URI: URI,
map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f)
};
Di sini, kita membuat objek array
yang mengimplementasikan Functor<'Array'>
. Implementasi map
hanyalah pembungkus di sekitar metode bawaan Array.prototype.map
.
Akhirnya, kita dapat menulis fungsi yang menggunakan abstraksi ini:
function doSomethingWithFunctor<F extends keyof URItoKind<any>>(
functor: Functor<F>
) {
return <A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B> => {
return functor.map(fa, f);
};
}
// Penggunaan:
const numbers = [1, 2, 3];
const double = (n: number) => n * 2;
// Kita meneruskan instance array untuk mendapatkan fungsi khusus
const mapForArray = doSomethingWithFunctor(array);
const doubledNumbers = mapForArray(numbers, double); // [2, 4, 6]
console.log(doubledNumbers); // Tipe disimpulkan dengan benar sebagai number[]
Ini berhasil! Kita telah menciptakan fungsi doSomethingWithFunctor
yang generik terhadap tipe kontainer F
. Ia tidak tahu apakah ia bekerja dengan Array
, Promise
, atau Option
. Ia hanya tahu bahwa ia memiliki instance Functor
untuk kontainer tersebut, yang menjamin adanya metode map
dengan tanda tangan yang benar.
Aplikasi Praktis: Membangun Abstraksi Fungsional
Functor
hanyalah permulaan. Motivasi utama untuk HKTs adalah untuk membangun hierarki kaya dari kelas tipe (antarmuka) yang menangkap pola komputasi umum. Mari kita lihat dua lagi yang penting: Applicative Functors dan Monads.
Applicative Functors: Menerapkan Fungsi dalam Konteks
Sebuah Functor memungkinkan Anda menerapkan fungsi normal ke nilai di dalam sebuah konteks (misalnya, `map(nilaiDalamKonteks, fungsiNormal)`). Sebuah Applicative Functor (atau hanya Applicative) membawa ini selangkah lebih maju: ia memungkinkan Anda menerapkan fungsi yang juga berada di dalam konteks ke nilai di dalam konteks.
Kelas tipe Applicative memperluas Functor dan menambahkan dua metode baru:
of
(juga dikenal sebagai `pure`): Mengambil nilai normal dan mengangkatnya ke dalam konteks. UntukArray
,of(x)
akan menjadi[x]
. UntukPromise
,of(x)
akan menjadiPromise.resolve(x)
.ap
: Mengambil sebuah kontainer yang berisi fungsi `(a: A) => B` dan sebuah kontainer yang berisi nilai `A`, dan mengembalikan sebuah kontainer yang berisi nilai `B`.
import { Functor } from './Functor';
import { Kind, URItoKind } from './hkt';
export interface Applicative<F extends keyof URItoKind<any>> extends Functor<F> {
readonly of: <A>(a: A) => Kind<F, A>;
readonly ap: <A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>) => Kind<F, B>;
}
Kapan ini berguna? Bayangkan Anda memiliki dua nilai dalam sebuah konteks, dan Anda ingin menggabungkannya dengan fungsi dua argumen. Misalnya, Anda memiliki dua input formulir yang mengembalikan `Option
// Asumsikan kita memiliki tipe Option dan instance Applicative-nya
const name: Option<string> = some('Alice');
const age: Option<number> = some(30);
const createUser = (name: string) => (age: number) => ({ name, age });
// Bagaimana kita menerapkan createUser ke nama dan umur?
// 1. Angkat fungsi curried ke dalam konteks Option
const curriedUserInOption = option.of(createUser);
// curriedUserInOption bertipe Option<(name: string) => (age: number) => User>
// 2. `map` tidak bekerja secara langsung. Kita butuh `ap`!
const userBuilderInOption = option.ap(option.map(curriedUserInOption, f => f), name);
// Ini agak canggung. Cara yang lebih baik:
const userBuilderInOption2 = option.map(name, createUser);
// userBuilderInOption2 bertipe Option<(age: number) => User>
// 3. Terapkan fungsi-dalam-konteks ke umur-dalam-konteks
const userInOption = option.ap(userBuilderInOption2, age);
// userInOption adalah Some({ name: 'Alice', age: 30 })
Pola ini sangat kuat untuk hal-hal seperti validasi formulir, di mana beberapa fungsi validasi independen mengembalikan hasil dalam sebuah konteks (seperti `Either
Monads: Mengurutkan Operasi dalam Konteks
Monad mungkin adalah abstraksi fungsional yang paling terkenal dan sering disalahpahami. Sebuah Monad digunakan untuk mengurutkan operasi di mana setiap langkah bergantung pada hasil dari langkah sebelumnya, dan setiap langkah mengembalikan nilai yang terbungkus dalam konteks yang sama.
Kelas tipe Monad memperluas Applicative dan menambahkan satu metode penting: chain
(juga dikenal sebagai `flatMap` atau `bind`).
import { Applicative } from './Applicative';
import { Kind, URItoKind } from './hkt';
export interface Monad<M extends keyof URItoKind<any>> extends Applicative<M> {
readonly chain: <A, B>(fa: Kind<M, A>, f: (a: A) => Kind<M, B>) => Kind<M, B>;
}
Perbedaan utama antara `map` dan `chain` adalah fungsi yang mereka terima:
map
mengambil fungsi(a: A) => B
. Ia menerapkan fungsi "normal".chain
mengambil fungsi(a: A) => Kind
. Ia menerapkan fungsi yang sendirinya mengembalikan nilai dalam konteks monadik.
chain
adalah apa yang mencegah Anda berakhir dengan konteks bersarang seperti Promise
atau Option
. Ia secara otomatis "meratakan" (flattens) hasilnya.
Contoh Klasik: Promises
Anda kemungkinan besar telah menggunakan Monads tanpa menyadarinya. `Promise.prototype.then` bertindak sebagai `chain` monadik (ketika callback mengembalikan `Promise` lain).
interface User { id: number; name: string; }
interface Post { userId: number; content: string; }
function getUser(id: number): Promise<User> {
return Promise.resolve({ id, name: 'Bob' });
}
function getLatestPost(user: User): Promise<Post> {
return Promise.resolve({ userId: user.id, content: 'Hello HKTs!' });
}
// Tanpa `chain` (`then`), Anda akan mendapatkan Promise bersarang:
const nestedPromise: Promise<Promise<Post>> = getUser(1).then(user => {
// `then` ini bertindak seperti `map` di sini
return getLatestPost(user); // mengembalikan sebuah Promise, menciptakan Promise<Promise<...>>
});
// Dengan `chain` monadik (`then` ketika ia meratakan), strukturnya bersih:
const postPromise: Promise<Post> = getUser(1).then(user => {
// `then` melihat kita mengembalikan Promise dan secara otomatis meratakannya.
return getLatestPost(user);
});
Menggunakan antarmuka Monad berbasis HKT memungkinkan Anda menulis fungsi yang generik terhadap komputasi sekuensial apa pun yang sadar konteks, baik itu operasi asinkron (`Promise`), operasi yang dapat gagal (`Either`, `Option`), atau komputasi dengan state bersama (`State`).
Masa Depan HKTs di TypeScript
Teknik emulasi yang telah kita diskusikan sangat kuat tetapi datang dengan konsekuensi. Mereka memperkenalkan sejumlah besar boilerplate dan kurva belajar yang curam. Pesan kesalahan dari kompiler TypeScript bisa menjadi samar ketika ada yang salah dengan pengkodean.
Jadi, bagaimana dengan dukungan bawaan? Permintaan untuk Higher-Kinded Types (atau mekanisme untuk mencapai tujuan yang sama) adalah salah satu isu yang paling lama ada dan paling banyak dibicarakan di repositori GitHub TypeScript. Tim TypeScript sadar akan permintaan tersebut, tetapi mengimplementasikan HKTs menyajikan tantangan yang signifikan:
- Kompleksitas Sintaksis: Menemukan sintaks yang bersih dan intuitif yang cocok dengan sistem tipe yang ada itu sulit. Proposal seperti
type F
atauF :: * -> *
telah dibahas, tetapi masing-masing memiliki pro dan kontra. - Tantangan Inferensi: Inferensi tipe, salah satu kekuatan terbesar TypeScript, menjadi jauh lebih kompleks dengan HKTs. Memastikan bahwa inferensi bekerja dengan andal dan berkinerja adalah rintangan utama.
- Kesejajaran dengan JavaScript: TypeScript bertujuan untuk sejajar dengan realitas runtime JavaScript. HKTs adalah konstruksi murni waktu kompilasi di tingkat tipe, yang dapat menciptakan kesenjangan konseptual antara sistem tipe dan runtime yang mendasarinya.
Meskipun dukungan bawaan mungkin tidak akan segera hadir, diskusi yang sedang berlangsung dan keberhasilan pustaka seperti `fp-ts`, `Effect`, dan `ts-toolbelt` membuktikan bahwa konsep-konsep tersebut berharga dan dapat diterapkan dalam konteks TypeScript. Pustaka-pustaka ini menyediakan pengkodean HKT yang tangguh dan siap pakai serta ekosistem abstraksi fungsional yang kaya, menyelamatkan Anda dari menulis boilerplate sendiri.
Kesimpulan: Level Abstraksi yang Baru
Higher-Kinded Types merepresentasikan lompatan signifikan dalam abstraksi tingkat tipe. Mereka memungkinkan kita untuk bergerak melampaui generik terhadap nilai dalam struktur data kita menjadi generik terhadap struktur itu sendiri. Dengan melakukan abstraksi terhadap kontainer seperti Array
, Promise
, Option
, dan Either
, kita dapat menulis fungsi dan antarmuka universal—seperti Functor, Applicative, dan Monad—yang menangkap pola komputasi fundamental.
Meskipun kurangnya dukungan bawaan TypeScript memaksa kita untuk bergantung pada pengkodean yang kompleks, manfaatnya bisa sangat besar bagi penulis pustaka dan pengembang aplikasi yang bekerja pada sistem yang besar dan kompleks. Memahami HKTs memungkinkan Anda untuk:
- Menulis Kode yang Lebih Dapat Digunakan Kembali: Definisikan logika yang berfungsi untuk struktur data apa pun yang sesuai dengan antarmuka tertentu (misalnya, `Functor`).
- Meningkatkan Keamanan Tipe: Terapkan kontrak tentang bagaimana struktur data seharusnya berperilaku di tingkat tipe, mencegah seluruh kelas bug.
- Menerapkan Pola Fungsional: Manfaatkan pola yang kuat dan terbukti dari dunia pemrograman fungsional untuk mengelola efek samping, menangani kesalahan, dan menulis kode yang deklaratif dan dapat disusun.
Perjalanan ke dalam HKTs memang menantang, tetapi ini adalah perjalanan yang memuaskan yang memperdalam pemahaman Anda tentang sistem tipe TypeScript dan membuka kemungkinan baru untuk menulis kode yang bersih, tangguh, dan elegan. Jika Anda ingin membawa keterampilan TypeScript Anda ke tingkat berikutnya, menjelajahi pustaka seperti fp-ts
dan membangun abstraksi berbasis HKT sederhana Anda sendiri adalah tempat yang sangat baik untuk memulai.